Micron Document
🎖️GitЯра🎖️


Displaying Raw • Download

feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt 2d20cd8a4708e2ef66e98d5259f3a97ec93f240a (2d20cd8a) Text, 36.42 KB

T8b949e/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
Tf0883e@fileTb4b4b4:Te6edf3SuppressTb4b4b4(Ta5d6ff"Ta5d6ffTooManyFunctionsTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffMagicNumberTa5d6ff"Tb4b4b4)

Tff7b72package T7ee787org.meshtastic.feature.discovery

Tff7b72import T7ee787co.touchlab.kermit.Logger
Tff7b72import T7ee787kotlinx.coroutines.CancellationException
Tff7b72import T7ee787kotlinx.coroutines.CoroutineScope
Tff7b72import T7ee787kotlinx.coroutines.Job
Tff7b72import T7ee787kotlinx.coroutines.SupervisorJob
Tff7b72import T7ee787kotlinx.coroutines.cancel
Tff7b72import T7ee787kotlinx.coroutines.delay
Tff7b72import T7ee787kotlinx.coroutines.flow.MutableStateFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.StateFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.asStateFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.first
Tff7b72import T7ee787kotlinx.coroutines.launch
Tff7b72import T7ee787kotlinx.coroutines.sync.Mutex
Tff7b72import T7ee787kotlinx.coroutines.sync.withLock
Tff7b72import T7ee787kotlinx.coroutines.withTimeoutOrNull
Tff7b72import T7ee787org.koin.core.annotation.Single
Tff7b72import T7ee787org.meshtastic.core.common.di.ApplicationCoroutineScope
Tff7b72import T7ee787org.meshtastic.core.common.util.latLongToMeter
Tff7b72import T7ee787org.meshtastic.core.common.util.nowMillis
Tff7b72import T7ee787org.meshtastic.core.database.dao.DiscoveryDao
Tff7b72import T7ee787org.meshtastic.core.database.entity.DiscoveredNodeEntity
Tff7b72import T7ee787org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
Tff7b72import T7ee787org.meshtastic.core.database.entity.DiscoverySessionEntity
Tff7b72import T7ee787org.meshtastic.core.di.CoroutineDispatchers
Tff7b72import T7ee787org.meshtastic.core.model.ChannelOption
Tff7b72import T7ee787org.meshtastic.core.model.ConnectionState
Tff7b72import T7ee787org.meshtastic.core.model.DataPacket
Tff7b72import T7ee787org.meshtastic.core.model.util.decodeOrNull
Tff7b72import T7ee787org.meshtastic.core.model.util.snrOrNull
Tff7b72import T7ee787org.meshtastic.core.repository.DiscoveryPacketCollector
Tff7b72import T7ee787org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
Tff7b72import T7ee787org.meshtastic.core.repository.MeshPrefs
Tff7b72import T7ee787org.meshtastic.core.repository.NodeRepository
Tff7b72import T7ee787org.meshtastic.core.repository.RadioConfigRepository
Tff7b72import T7ee787org.meshtastic.core.repository.RadioController
Tff7b72import T7ee787org.meshtastic.core.repository.ServiceRepository
Tff7b72import T7ee787org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
Tff7b72import T7ee787org.meshtastic.proto.Channel
Tff7b72import T7ee787org.meshtastic.proto.ChannelSettings
Tff7b72import T7ee787org.meshtastic.proto.Config
Tff7b72import T7ee787org.meshtastic.proto.MeshPacket
Tff7b72import T7ee787org.meshtastic.proto.NeighborInfo
Tff7b72import T7ee787org.meshtastic.proto.PortNum
Tff7b72import T7ee787org.meshtastic.proto.Position
Tff7b72import T7ee787org.meshtastic.proto.Telemetry

T8b949e/**
* Core scan engine for Local Mesh Discovery.
*
* Cycles through a queue of LoRa presets, dwells on each for a configured duration while collecting packets, then
* persists aggregated results via [DiscoveryDao].
*/
Tf0883e@Single
Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffLongParameterListTa5d6ff"Tb4b4b4)
Tff7b72class T56d364DiscoveryScanEngineTb4b4b4(
Tff7b72private Tff7b72val Te6edf3radioControllerTb4b4b4: Te6edf3RadioControllerTb4b4b4,
Tff7b72private Tff7b72val Te6edf3serviceRepositoryTb4b4b4: Te6edf3ServiceRepositoryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3nodeRepositoryTb4b4b4: Te6edf3NodeRepositoryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3radioConfigRepositoryTb4b4b4: Te6edf3RadioConfigRepositoryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3collectorRegistryTb4b4b4: Te6edf3DiscoveryPacketCollectorRegistryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3discoveryDaoTb4b4b4: Te6edf3DiscoveryDaoTb4b4b4,
Tff7b72private Tff7b72val Te6edf3aiProviderTb4b4b4: Te6edf3DiscoverySummaryAiProviderTb4b4b4,
Tff7b72private Tff7b72val Te6edf3applicationScopeTb4b4b4: Te6edf3ApplicationCoroutineScopeTb4b4b4,
Tff7b72private Tff7b72val Te6edf3dispatchersTb4b4b4: Te6edf3CoroutineDispatchersTb4b4b4,
Tff7b72private Tff7b72val Te6edf3meshPrefsTb4b4b4: Te6edf3MeshPrefsTb4b4b4,
Tb4b4b4) Tb4b4b4: Te6edf3DiscoveryPacketCollector Tb4b4b4{

T8b949e// region Public state

Tff7b72private Tff7b72val Te6edf3_scanState Tff7b72= Te6edf3MutableStateFlowTff7b72<Te6edf3DiscoveryScanStateTff7b72>Tb4b4b4(Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3IdleTb4b4b4)
Tff7b72val Te6edf3scanStateTb4b4b4: Te6edf3StateFlowTff7b72<Te6edf3DiscoveryScanStateTff7b72> Tff7b72= Te6edf3_scanStateTb4b4b4.Te6edf3asStateFlowTb4b4b4(Tb4b4b4)

Tff7b72private Tff7b72val Te6edf3_currentSession Tff7b72= Te6edf3MutableStateFlowTff7b72<Te6edf3DiscoverySessionEntity?Tff7b72>Tb4b4b4(Tff7b72nullTb4b4b4)
Tff7b72val Te6edf3currentSessionTb4b4b4: Te6edf3StateFlowTff7b72<Te6edf3DiscoverySessionEntity?Tff7b72> Tff7b72= Te6edf3_currentSessionTb4b4b4.Te6edf3asStateFlowTb4b4b4(Tb4b4b4)

Tff7b72override Tff7b72val Te6edf3isActiveTb4b4b4: Tffa657Boolean
Tff7b72getTb4b4b4(Tb4b4b4) Tff7b72=
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Idle Tff7b72&Tff7b72&
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Complete Tff7b72&Tff7b72&
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Failed

T8b949e// endregion

T8b949e// region Internal scan state

Tff7b72private Tff7b72val Te6edf3mutex Tff7b72= Te6edf3MutexTb4b4b4(Tb4b4b4)
Tff7b72private Tff7b72var Te6edf3scanScopeTb4b4b4: Te6edf3CoroutineScope? Tff7b72= Tff7b72null
Tff7b72private Tff7b72var Te6edf3dwellJobTb4b4b4: Te6edf3Job? Tff7b72= Tff7b72null
Tff7b72private Tff7b72var Te6edf3originalLoRaConfigTb4b4b4: Te6edf3ConfigTb4b4b4.Te6edf3LoRaConfigTff7b72? Tff7b72= Tff7b72null

T8b949e/** The radio's primary channel at scan start; restored after a custom-channel target overwrites it (FR-005). */
Tff7b72private Tff7b72var Te6edf3originalPrimaryChannelTb4b4b4: Te6edf3ChannelSettings? Tff7b72= Tff7b72null

T8b949e/** True once a custom-channel target has retuned the primary channel, so restore only writes when needed. */
Tff7b72private Tff7b72var Te6edf3tunedPrimaryChannelTb4b4b4: Tffa657Boolean Tff7b72= Tff7b72false
Tff7b72private Tff7b72var Te6edf3sessionIdTb4b4b4: Tffa657Long Tff7b72= T79c0ff0

T8b949e/** Nodes collected for the current preset dwell. Keyed by nodeNum. */
Tff7b72private Tff7b72val Te6edf3collectedNodes Tff7b72= Te6edf3mutableMapOfTff7b72<Tffa657LongTb4b4b4, Te6edf3CollectedNodeDataTff7b72>Tb4b4b4(Tb4b4b4)

T8b949e/** DeviceMetrics entries per node for the 2-packet rule. Keyed by nodeNum. */
Tff7b72private Tff7b72val Te6edf3deviceMetricsLog Tff7b72= Te6edf3mutableMapOfTff7b72<Tffa657LongTb4b4b4, Te6edf3MutableListTff7b72<Te6edf3DeviceMetricsEntryTff7b72>Tff7b72>Tb4b4b4(Tb4b4b4)

Tff7b72private Tff7b72var Te6edf3currentPresetNameTb4b4b4: Tffa657String Tff7b72= Ta5d6ff"Ta5d6ff"
Tff7b72private Tff7b72var Te6edf3totalDwellSecondsTb4b4b4: Tffa657Long Tff7b72= T79c0ff0
Tff7b72private Tff7b72var Te6edf3lastLocalStatsTb4b4b4: Te6edf3orgTb4b4b4.Te6edf3meshtasticTb4b4b4.Te6edf3protoTb4b4b4.Te6edf3LocalStatsTff7b72? Tff7b72= Tff7b72null

T8b949e// endregion

T8b949e// region Internal data classes

Tff7b72private Tff7b72data Tff7b72class T56d364CollectedNodeDataTb4b4b4(
Tff7b72var Te6edf3nodeNumTb4b4b4: Tffa657LongTb4b4b4,
Tff7b72var Te6edf3shortNameTb4b4b4: Tffa657String? Tff7b72= Tff7b72nullTb4b4b4,
Tff7b72var Te6edf3longNameTb4b4b4: Tffa657String? Tff7b72= Tff7b72nullTb4b4b4,
Tff7b72var Te6edf3neighborTypeTb4b4b4: Tffa657String Tff7b72= Ta5d6ff"Ta5d6ffdirectTa5d6ff"Tb4b4b4,
Tff7b72var Te6edf3latitudeTb4b4b4: Tffa657Double? Tff7b72= Tff7b72nullTb4b4b4,
Tff7b72var Te6edf3longitudeTb4b4b4: Tffa657Double? Tff7b72= Tff7b72nullTb4b4b4,
Tff7b72var Te6edf3snrTb4b4b4: Tffa657Float Tff7b72= T79c0ff0fTb4b4b4,
T8b949e/** Null until a packet reports one, so an absent reading stays distinct from a valid 0 dBm. */
Tff7b72var Te6edf3rssiTb4b4b4: Tffa657Int? Tff7b72= Tff7b72nullTb4b4b4,
Tff7b72var Te6edf3hopCountTb4b4b4: Tffa657Int Tff7b72= T79c0ff0Tb4b4b4,
Tff7b72var Te6edf3messageCountTb4b4b4: Tffa657Int Tff7b72= T79c0ff0Tb4b4b4,
Tff7b72var Te6edf3sensorPacketCountTb4b4b4: Tffa657Int Tff7b72= T79c0ff0Tb4b4b4,
Tff7b72var Te6edf3isInfrastructureTb4b4b4: Tffa657Boolean Tff7b72= Tff7b72falseTb4b4b4,
Tb4b4b4)

Tff7b72private Tff7b72data Tff7b72class T56d364DeviceMetricsEntryTb4b4b4(Tff7b72val Te6edf3timestampTb4b4b4: Tffa657LongTb4b4b4, Tff7b72val Te6edf3channelUtilTb4b4b4: Tffa657DoubleTb4b4b4, Tff7b72val Te6edf3airUtilTxTb4b4b4: Tffa657DoubleTb4b4b4)

T8b949e// endregion

T8b949e// region Public API

T8b949e/** Convenience overload: scan a list of public presets (each becomes a preset [ScanTarget] labelled by name). */
Tff7b72suspend Tff7b72fun Td2a8ffstartScanTb4b4b4(Te6edf3presetsTb4b4b4: Te6edf3ListTff7b72<Te6edf3ChannelOptionTff7b72>Tb4b4b4, Te6edf3dwellDurationSecondsTb4b4b4: Tffa657LongTb4b4b4) Tff7b72=
Te6edf3startScanTargetsTb4b4b4(Te6edf3presetsTb4b4b4.Te6edf3map Tb4b4b4{ Te6edf3ScanTargetTb4b4b4(Te6edf3preset Tff7b72= Tffa657itTb4b4b4, Te6edf3label Tff7b72= Tffa657itTb4b4b4.Te6edf3nameTb4b4b4) Tb4b4b4}Tb4b4b4, Te6edf3dwellDurationSecondsTb4b4b4)

T8b949e/**
* Starts a discovery scan across the given [targets] — public presets and/or beacon-advertised custom channels.
* (Distinct name from the [ChannelOption] overload: both would erase to the same JVM signature otherwise.)
*
* @param targets The scan queue ([ScanTarget]).
* @param dwellDurationSeconds How long to listen on each target.
*/
Tff7b72suspend Tff7b72fun Td2a8ffstartScanTargetsTb4b4b4(Te6edf3targetsTb4b4b4: Te6edf3ListTff7b72<Te6edf3ScanTargetTff7b72>Tb4b4b4, Te6edf3dwellDurationSecondsTb4b4b4: Tffa657LongTb4b4b4) Tb4b4b4{
Te6edf3requireTb4b4b4(Te6edf3targetsTb4b4b4.Te6edf3isNotEmptyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ffAt least one scan target is requiredTa5d6ff" Tb4b4b4}
Te6edf3requireTb4b4b4(Te6edf3dwellDurationSeconds Tff7b72> T79c0ff0Tb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ffDwell duration must be positiveTa5d6ff" Tb4b4b4}

Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3isActiveTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: scan already active, ignoring startScanTa5d6ff" Tb4b4b4}
Tff7b72return
Tb4b4b4}

Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Preparing

T8b949e// Capture the entire original LoRa config and primary channel to restore them accurately later.
Tff7b72val Te6edf3initialLoraConfig Tff7b72= Te6edf3radioConfigRepositoryTb4b4b4.Te6edf3localConfigFlowTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3lora
Te6edf3originalLoRaConfig Tff7b72= Te6edf3initialLoraConfig
Te6edf3originalPrimaryChannel Tff7b72= Te6edf3radioConfigRepositoryTb4b4b4.Te6edf3channelSetFlowTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3settingsTb4b4b4.Te6edf3firstOrNullTb4b4b4(Tb4b4b4)
Te6edf3tunedPrimaryChannel Tff7b72= Tff7b72false

T8b949e// A custom-channel target overwrites the primary channel; without a captured original we could not restore
T8b949e// it and would strand the radio on the beacon's channel. Abort rather than proceed silently.
Tff7b72if Tb4b4b4(Te6edf3originalPrimaryChannel Tff7b72=Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3targetsTb4b4b4.Te6edf3any Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3channel Tff7b72!Tff7b72= Tff7b72null Tb4b4b4}Tb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: primary channel not captured; aborting custom-channel scanTa5d6ff" Tb4b4b4}
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Idle
Tff7b72return
Tb4b4b4}

Tff7b72val Te6edf3homePresetStr Tff7b72=
Tff7b72if Tb4b4b4(Te6edf3initialLoraConfigTff7b72?.Te6edf3use_preset Tff7b72=Tff7b72= Tff7b72trueTb4b4b4) Tb4b4b4{
Te6edf3ChannelOptionTb4b4b4.Te6edf3fromTb4b4b4(Te6edf3initialLoraConfigTb4b4b4.Te6edf3modem_presetTb4b4b4)Tff7b72?.Te6edf3name Tff7b72?: Te6edf3ChannelOptionTb4b4b4.Te6edf3DEFAULTTb4b4b4.Te6edf3name
Tb4b4b4} Tff7b72else Tb4b4b4{
Ta5d6ff"Ta5d6ffCUSTOMTa5d6ff"
Tb4b4b4}

Tff7b72val Te6edf3myNodeNum Tff7b72= Te6edf3nodeRepositoryTb4b4b4.Te6edf3myNodeInfoTb4b4b4.Te6edf3valueTff7b72?.Te6edf3myNodeNum
Tff7b72val Te6edf3myPosition Tff7b72= Te6edf3myNodeNumTff7b72?.Te6edf3let Tb4b4b4{ Te6edf3nodeRepositoryTb4b4b4.Te6edf3nodeDBbyNumTb4b4b4.Te6edf3valueTff7b72[Tffa657itTff7b72]Tff7b72?.Te6edf3position Tb4b4b4}
Tff7b72val Te6edf3latDouble Tff7b72= Tb4b4b4(Te6edf3myPositionTff7b72?.Te6edf3latitude_i Tff7b72?: T79c0ff0Tb4b4b4)Tb4b4b4.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72/ Te6edf3POSITION_DIVISOR
Tff7b72val Te6edf3lonDouble Tff7b72= Tb4b4b4(Te6edf3myPositionTff7b72?.Te6edf3longitude_i Tff7b72?: T79c0ff0Tb4b4b4)Tb4b4b4.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72/ Te6edf3POSITION_DIVISOR

T8b949e// Create the DB session. homeLoraConfig/homePrimaryChannel/deviceAddress let a later reconnect detect and
T8b949e// restore this exact config if the process dies mid-scan before restoreHomePreset() ever runs.
Tff7b72val Te6edf3session Tff7b72=
Te6edf3DiscoverySessionEntityTb4b4b4(
Te6edf3timestamp Tff7b72= Te6edf3nowMillisTb4b4b4,
Te6edf3presetsScanned Tff7b72= Te6edf3targetsTb4b4b4.Te6edf3joinToStringTb4b4b4(Ta5d6ff"Ta5d6ff,Ta5d6ff"Tb4b4b4) Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3label Tb4b4b4}Tb4b4b4,
Te6edf3homePreset Tff7b72= Te6edf3homePresetStrTb4b4b4,
Te6edf3completionStatus Tff7b72= Ta5d6ff"Ta5d6ffin_progressTa5d6ff"Tb4b4b4,
Te6edf3userLatitude Tff7b72= Te6edf3latDoubleTb4b4b4,
Te6edf3userLongitude Tff7b72= Te6edf3lonDoubleTb4b4b4,
Te6edf3deviceAddress Tff7b72= Te6edf3meshPrefsTb4b4b4.Te6edf3deviceAddressTb4b4b4.Te6edf3valueTb4b4b4,
Te6edf3homeLoraConfig Tff7b72= Te6edf3initialLoraConfigTb4b4b4,
T8b949e// Only custom-channel targets ever overwrite the primary channel, so only they need it restored —
T8b949e// mirrors the tunedPrimaryChannel gating in restoreHomePreset().
Te6edf3homePrimaryChannel Tff7b72= Te6edf3originalPrimaryChannelTb4b4b4.Te6edf3takeIf Tb4b4b4{ Te6edf3targetsTb4b4b4.Te6edf3any Tb4b4b4{ Te6edf3t Tff7b72-Tff7b72> Te6edf3tTb4b4b4.Te6edf3channel Tff7b72!Tff7b72= Tff7b72null Tb4b4b4} Tb4b4b4}Tb4b4b4,
Tb4b4b4)
Te6edf3sessionId Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3insertSessionTb4b4b4(Te6edf3sessionTb4b4b4)
Te6edf3_currentSessionTb4b4b4.Te6edf3value Tff7b72= Te6edf3sessionTb4b4b4.Te6edf3copyTb4b4b4(Te6edf3id Tff7b72= Te6edf3sessionIdTb4b4b4)

T8b949e// Register as packet collector
Te6edf3collectorRegistryTb4b4b4.Te6edf3collector Tff7b72= Tff7b72this

T8b949e// Set initial state so the scan loop's isActive guard succeeds
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3ShiftingTb4b4b4(Te6edf3targetsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3labelTb4b4b4)
Te6edf3currentPresetName Tff7b72= Te6edf3targetsTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3label
Te6edf3totalDwellSeconds Tff7b72= Te6edf3dwellDurationSeconds

T8b949e// Launch scan coroutine
Tff7b72val Te6edf3scope Tff7b72= Te6edf3CoroutineScopeTb4b4b4(Te6edf3dispatchersTb4b4b4.Te6edf3io Tff7b72+ Te6edf3SupervisorJobTb4b4b4(Tb4b4b4)Tb4b4b4)
Te6edf3scanScope Tff7b72= Te6edf3scope
Te6edf3scopeTb4b4b4.Te6edf3launch Tb4b4b4{ Te6edf3runScanLoopTb4b4b4(Te6edf3targetsTb4b4b4, Te6edf3dwellDurationSecondsTb4b4b4) Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

T8b949e/** Stops the active scan and restores the home preset. */
Tff7b72suspend Tff7b72fun Td2a8ffstopScanTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3isActiveTb4b4b4) Tff7b72return
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: stopping scanTa5d6ff" Tb4b4b4}
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Cancelling
Te6edf3cancelScanInternalTb4b4b4(Tb4b4b4)
Tb4b4b4}
Te6edf3persistCurrentDwellResultsTb4b4b4(Tb4b4b4)
Te6edf3finalizeSessionTb4b4b4(Ta5d6ff"Ta5d6ffstoppedTa5d6ff"Tb4b4b4)
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CompleteTb4b4b4(Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CompletionOutcomeTb4b4b4.Te6edf3CancelledTb4b4b4)

T8b949e// Restore home preset in the background so we don't block the UI with the connection wait
Te6edf3applicationScopeTb4b4b4.Te6edf3launch Tb4b4b4{ Te6edf3restoreHomePresetTb4b4b4(Tb4b4b4) Tb4b4b4}
Tb4b4b4}

T8b949e/** Resets engine state after the UI has acknowledged completion. */
Tff7b72fun Td2a8ffresetTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Idle
Te6edf3_currentSessionTb4b4b4.Te6edf3value Tff7b72= Tff7b72null
Tb4b4b4}

T8b949e// endregion

T8b949e// region DiscoveryPacketCollector

Tff7b72override Tff7b72suspend Tff7b72fun Td2a8ffonPacketReceivedTb4b4b4(Te6edf3meshPacketTb4b4b4: Te6edf3MeshPacketTb4b4b4, Te6edf3dataPacketTb4b4b4: Te6edf3DataPacketTb4b4b4) Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3DwellTb4b4b4) Tff7b72return
Tff7b72val Te6edf3fromNum Tff7b72= Te6edf3meshPacketTb4b4b4.Te6edf3fromTb4b4b4.Te6edf3toLongTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3portNum Tff7b72= Te6edf3meshPacketTb4b4b4.Te6edf3decodedTff7b72?.Te6edf3portnum Tff7b72?: Tff7b72return

Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72val Te6edf3node Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3getOrPutTb4b4b4(Te6edf3fromNumTb4b4b4) Tb4b4b4{ Te6edf3CollectedNodeDataTb4b4b4(Te6edf3nodeNum Tff7b72= Te6edf3fromNumTb4b4b4) Tb4b4b4}
T8b949e// Update signal info from the direct packet
T8b949e// Explicit presence: record a reported 0 dB/0 dBm, skip only a genuinely absent one.
Te6edf3meshPacketTb4b4b4.Te6edf3snrOrNullTb4b4b4(Tb4b4b4)Tff7b72?.Te6edf3let Tb4b4b4{ Te6edf3nodeTb4b4b4.Te6edf3snr Tff7b72= Tffa657it Tb4b4b4}
Te6edf3meshPacketTb4b4b4.Te6edf3rx_rssiTff7b72?.Te6edf3let Tb4b4b4{ Te6edf3nodeTb4b4b4.Te6edf3rssi Tff7b72= Tffa657it Tb4b4b4}
Te6edf3nodeTb4b4b4.Te6edf3hopCount Tff7b72= Te6edf3dataPacketTb4b4b4.Te6edf3hopsAwayTb4b4b4.Te6edf3coerceAtLeastTb4b4b4(T79c0ff0Tb4b4b4)

Tff7b72when Tb4b4b4(Te6edf3portNumTb4b4b4) Tb4b4b4{
Te6edf3PortNumTb4b4b4.Te6edf3TEXT_MESSAGE_APP Tff7b72-Tff7b72> Te6edf3nodeTb4b4b4.Te6edf3messageCountTff7b72+Tff7b72+
Te6edf3PortNumTb4b4b4.Te6edf3POSITION_APP Tff7b72-Tff7b72> Te6edf3handlePositionTb4b4b4(Te6edf3meshPacketTb4b4b4, Te6edf3nodeTb4b4b4)
Te6edf3PortNumTb4b4b4.Te6edf3TELEMETRY_APP Tff7b72-Tff7b72> Te6edf3handleTelemetryTb4b4b4(Te6edf3meshPacketTb4b4b4, Te6edf3nodeTb4b4b4, Te6edf3fromNumTb4b4b4)
Te6edf3PortNumTb4b4b4.Te6edf3NEIGHBORINFO_APP Tff7b72-Tff7b72> Te6edf3handleNeighborInfoTb4b4b4(Te6edf3meshPacketTb4b4b4)
Tff7b72else Tff7b72-Tff7b72> Tffa657Unit
Tb4b4b4}

T8b949e// Enrich the sending node from the local NodeDB (names/position fallback)
Te6edf3enrichNodeFromDbTb4b4b4(Te6edf3nodeTb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/** Backfills name, position, and infrastructure role from the local NodeDB when not yet received over-the-air. */
Tff7b72private Tff7b72fun Td2a8ffenrichNodeFromDbTb4b4b4(Te6edf3nodeTb4b4b4: Te6edf3CollectedNodeDataTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3dbNode Tff7b72= Te6edf3nodeRepositoryTb4b4b4.Te6edf3nodeDBbyNumTb4b4b4.Te6edf3valueTff7b72[Te6edf3nodeTb4b4b4.Te6edf3nodeNumTb4b4b4.Te6edf3toIntTb4b4b4(Tb4b4b4)Tff7b72] Tff7b72?: Tff7b72return
Tff7b72if Tb4b4b4(Te6edf3nodeTb4b4b4.Te6edf3shortName Tff7b72=Tff7b72= Tff7b72null Tff7b72|Tff7b72| Te6edf3nodeTb4b4b4.Te6edf3longName Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3nodeTb4b4b4.Te6edf3shortName Tff7b72= Te6edf3dbNodeTb4b4b4.Te6edf3userTb4b4b4.Te6edf3short_nameTb4b4b4.Te6edf3ifBlank Tb4b4b4{ Tff7b72null Tb4b4b4}
Te6edf3nodeTb4b4b4.Te6edf3longName Tff7b72= Te6edf3dbNodeTb4b4b4.Te6edf3userTb4b4b4.Te6edf3long_nameTb4b4b4.Te6edf3ifBlank Tb4b4b4{ Tff7b72null Tb4b4b4}
Tb4b4b4}
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3hasValidCoordinatesTb4b4b4(Te6edf3nodeTb4b4b4.Te6edf3latitudeTb4b4b4, Te6edf3nodeTb4b4b4.Te6edf3longitudeTb4b4b4)Tb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3dbLat Tff7b72= Te6edf3dbNodeTb4b4b4.Te6edf3positionTb4b4b4.Te6edf3latitude_i
Tff7b72val Te6edf3dbLon Tff7b72= Te6edf3dbNodeTb4b4b4.Te6edf3positionTb4b4b4.Te6edf3longitude_i
Tff7b72if Tb4b4b4(Te6edf3dbLat Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3dbLat Tff7b72!Tff7b72= T79c0ff0Tb4b4b4) Te6edf3nodeTb4b4b4.Te6edf3latitude Tff7b72= Te6edf3dbLatTb4b4b4.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72/ Te6edf3POSITION_DIVISOR
Tff7b72if Tb4b4b4(Te6edf3dbLon Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3dbLon Tff7b72!Tff7b72= T79c0ff0Tb4b4b4) Te6edf3nodeTb4b4b4.Te6edf3longitude Tff7b72= Te6edf3dbLonTb4b4b4.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72/ Te6edf3POSITION_DIVISOR
Tb4b4b4}
Te6edf3nodeTb4b4b4.Te6edf3isInfrastructure Tff7b72= Te6edf3dbNodeTb4b4b4.Te6edf3userTb4b4b4.Te6edf3role Tff7b72in Te6edf3INFRASTRUCTURE_ROLES
Tb4b4b4}

T8b949e// endregion

T8b949e// region Scan loop

Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffReturnCountTa5d6ff"Tb4b4b4)
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffrunScanLoopTb4b4b4(Te6edf3targetsTb4b4b4: Te6edf3ListTff7b72<Te6edf3ScanTargetTff7b72>Tb4b4b4, Te6edf3dwellDurationSecondsTb4b4b4: Tffa657LongTb4b4b4) Tb4b4b4{
Tff7b72for Tb4b4b4(Te6edf3target Tff7b72in Te6edf3targetsTb4b4b4) Tb4b4b4{
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3isActiveTb4b4b4) Tff7b72return

Te6edf3currentPresetName Tff7b72= Te6edf3targetTb4b4b4.Te6edf3label
Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Te6edf3collectedNodesTb4b4b4.Te6edf3clearTb4b4b4(Tb4b4b4)
Te6edf3deviceMetricsLogTb4b4b4.Te6edf3clearTb4b4b4(Tb4b4b4)
Te6edf3lastLocalStats Tff7b72= Tff7b72null
Tb4b4b4}
Te6edf3totalDwellSeconds Tff7b72= Te6edf3dwellDurationSeconds

T8b949e// Shift to the new target (preset, plus a custom primary channel for beacon-channel targets)
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3ShiftingTb4b4b4(Te6edf3targetTb4b4b4.Te6edf3labelTb4b4b4)
Te6edf3shiftTargetTb4b4b4(Te6edf3targetTb4b4b4)

T8b949e// Wait for reconnection
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3ReconnectingTb4b4b4(Te6edf3targetTb4b4b4.Te6edf3labelTb4b4b4)
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3waitForConnectionTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3pauseAndAbortTb4b4b4(Tb4b4b4)
Tff7b72return
Tb4b4b4}

T8b949e// Request neighbor info at dwell start to seed mesh topology data (D020)
Te6edf3requestNeighborInfoAtDwellBoundaryTb4b4b4(Tb4b4b4)

T8b949e// Dwell
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3runDwellTb4b4b4(Te6edf3targetTb4b4b4.Te6edf3labelTb4b4b4, Te6edf3dwellDurationSecondsTb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3pauseAndAbortTb4b4b4(Tb4b4b4)
Tff7b72return
Tb4b4b4}
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3isActiveTb4b4b4) Tff7b72return

T8b949e// Persist this target's results
Te6edf3persistCurrentDwellResultsTb4b4b4(Tb4b4b4)
Tb4b4b4}

T8b949e// All presets scanned — unregister packet collector before analysis
Te6edf3collectorRegistryTb4b4b4.Te6edf3collector Tff7b72= Tff7b72null
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3Analysis
Te6edf3restoreHomePresetTb4b4b4(Tb4b4b4)
Te6edf3generateAiSummariesTb4b4b4(Tb4b4b4)
Te6edf3finalizeSessionTb4b4b4(Ta5d6ff"Ta5d6ffcompleteTa5d6ff"Tb4b4b4)
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CompleteTb4b4b4(Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CompletionOutcomeTb4b4b4.Te6edf3SuccessTb4b4b4)
Tb4b4b4}

T8b949e/** Common cleanup path when a scan step fails mid-loop. */
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffpauseAndAbortTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3FailedTb4b4b4(Ta5d6ff"Ta5d6ffConnection lost during scanTa5d6ff"Tb4b4b4)
T8b949e// pauseAndAbort runs inside the runScanLoop coroutine, which is a child of scanScope.
T8b949e// cancelScanInternal() cancels scanScope (and therefore this coroutine), so it must run LAST:
T8b949e// any suspend after it — finalizeSession or restoreHomePreset — would throw CancellationException
T8b949e// and silently skip cleanup, stranding the radio on the scan modem preset. So finalize and reach
T8b949e// the terminal state first, then restore the home preset on applicationScope (which outlives
T8b949e// scanScope), mirroring stopScan().
Te6edf3finalizeSessionTb4b4b4(Ta5d6ff"Ta5d6fffailedTa5d6ff"Tb4b4b4)
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CompleteTb4b4b4(Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3CompletionOutcomeTb4b4b4.Te6edf3FailedTb4b4b4)
Te6edf3applicationScopeTb4b4b4.Te6edf3launch Tb4b4b4{ Te6edf3restoreHomePresetTb4b4b4(Tb4b4b4) Tb4b4b4}
Te6edf3cancelScanInternalTb4b4b4(Tb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffshiftTargetTb4b4b4(Te6edf3targetTb4b4b4: Te6edf3ScanTargetTb4b4b4) Tb4b4b4{
T8b949e// Start from the captured original config so unrelated fields (hop_limit, tx_power, tx_enabled, …) are carried
T8b949e// over instead of zeroed by a fresh LoRaConfig — a from-scratch config can e.g. break the dwell-boundary
T8b949e// NeighborInfo request. Only the preset (and, for custom channels, region + channel_num) is overridden.
Tff7b72val Te6edf3base Tff7b72= Te6edf3originalLoRaConfig Tff7b72?: Te6edf3ConfigTb4b4b4.Te6edf3LoRaConfigTb4b4b4(Tb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3targetTb4b4b4.Te6edf3channel Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
T8b949e// Public-preset target — dwell on the preset using the radio's existing primary channel (unchanged).
Te6edf3radioControllerTb4b4b4.Te6edf3setLocalConfigTb4b4b4(
Te6edf3ConfigTb4b4b4(Te6edf3lora Tff7b72= Te6edf3baseTb4b4b4.Te6edf3copyTb4b4b4(Te6edf3use_preset Tff7b72= Tff7b72trueTb4b4b4, Te6edf3modem_preset Tff7b72= Te6edf3targetTb4b4b4.Te6edf3presetTb4b4b4.Te6edf3modemPresetTb4b4b4)Tb4b4b4)Tb4b4b4,
Tb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: shifted to Tffd700${Te6edf3targetTb4b4b4.Te6edf3labelTffd700}Ta5d6ff (use_preset=true)Ta5d6ff" Tb4b4b4}
Tb4b4b4} Tff7b72else Tb4b4b4{
T8b949e// Beacon custom-channel target: apply the offered preset+region, reset channel_num so firmware derives the
T8b949e// frequency from the new name, then tune the primary channel to the offered name+PSK so nodes on that mesh
T8b949e// are heard. The original primary channel is restored after the scan.
Te6edf3radioControllerTb4b4b4.Te6edf3setLocalConfigTb4b4b4(
Te6edf3ConfigTb4b4b4(
Te6edf3lora Tff7b72=
Te6edf3baseTb4b4b4.Te6edf3copyTb4b4b4(
Te6edf3use_preset Tff7b72= Tff7b72trueTb4b4b4,
Te6edf3modem_preset Tff7b72= Te6edf3targetTb4b4b4.Te6edf3presetTb4b4b4.Te6edf3modemPresetTb4b4b4,
Te6edf3region Tff7b72= Te6edf3targetTb4b4b4.Te6edf3region Tff7b72?: Te6edf3baseTb4b4b4.Te6edf3regionTb4b4b4,
Te6edf3channel_num Tff7b72= T79c0ff0Tb4b4b4,
Tb4b4b4)Tb4b4b4,
Tb4b4b4)Tb4b4b4,
Tb4b4b4)
Te6edf3radioControllerTb4b4b4.Te6edf3setLocalChannelTb4b4b4(Te6edf3ChannelTb4b4b4(Te6edf3index Tff7b72= T79c0ff0Tb4b4b4, Te6edf3role Tff7b72= Te6edf3ChannelTb4b4b4.Te6edf3RoleTb4b4b4.Te6edf3PRIMARYTb4b4b4, Te6edf3settings Tff7b72= Te6edf3targetTb4b4b4.Te6edf3channelTb4b4b4)Tb4b4b4)
Te6edf3tunedPrimaryChannel Tff7b72= Tff7b72true
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: shifted to Tffd700${Te6edf3targetTb4b4b4.Te6edf3labelTffd700}Ta5d6ff (custom channel)Ta5d6ff" Tb4b4b4}
Tb4b4b4}
T8b949e// The firmware often restarts the radio or reboots after a LoRa config change.
T8b949e// Wait a short moment to ensure we don't consider it 'connected' right before it drops.
Te6edf3delayTb4b4b4(T79c0ff3T79c0ff0T79c0ff0T79c0ff0Tb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffwaitForConnectionTb4b4b4(Tb4b4b4)Tb4b4b4: Tffa657Boolean Tb4b4b4{
Tff7b72val Te6edf3result Tff7b72=
Te6edf3withTimeoutOrNullTb4b4b4(Te6edf3RECONNECT_TIMEOUT_MSTb4b4b4) Tb4b4b4{
Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3first Tb4b4b4{ Tffa657it Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Connected Tb4b4b4}
Tb4b4b4}
Tff7b72return Te6edf3result Tff7b72!Tff7b72= Tff7b72null
Tb4b4b4}

T8b949e/**
* Requests NeighborInfo from the local node at each dwell boundary to seed mesh topology data. The response arrives
* via the normal packet pipeline → [handleNeighborInfo].
*/
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffrequestNeighborInfoAtDwellBoundaryTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3myNodeNum Tff7b72= Te6edf3nodeRepositoryTb4b4b4.Te6edf3myNodeInfoTb4b4b4.Te6edf3valueTff7b72?.Te6edf3myNodeNum Tff7b72?: Tff7b72return
Tff7b72val Te6edf3packetId Tff7b72= Te6edf3radioControllerTb4b4b4.Te6edf3generatePacketIdTb4b4b4(Tb4b4b4)
Te6edf3radioControllerTb4b4b4.Te6edf3requestNeighborInfoTb4b4b4(Te6edf3packetIdTb4b4b4, Te6edf3myNodeNumTb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3d Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: requested NeighborInfo from local node Tffd700$Te6edf3myNodeNumTa5d6ff (packetId=Tffd700$Te6edf3packetIdTa5d6ff)Ta5d6ff" Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffrunDwellTb4b4b4(Te6edf3presetNameTb4b4b4: Tffa657StringTb4b4b4, Te6edf3durationSecondsTb4b4b4: Tffa657LongTb4b4b4)Tb4b4b4: Tffa657Boolean Tb4b4b4{
Tff7b72var Te6edf3remaining Tff7b72= Te6edf3durationSeconds
Tff7b72while Tb4b4b4(Te6edf3remaining Tff7b72> T79c0ff0 Tff7b72&Tff7b72& Te6edf3isActiveTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3isConnected Tff7b72= Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Connected
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3isConnectedTb4b4b4) Tb4b4b4{
Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3ReconnectingTb4b4b4(Te6edf3presetNameTb4b4b4)
Tff7b72val Te6edf3reconnected Tff7b72= Te6edf3waitForConnectionTb4b4b4(Tb4b4b4)
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3reconnectedTb4b4b4) Tff7b72return Tff7b72false
Tff7b72continue
Tb4b4b4}

Te6edf3_scanStateTb4b4b4.Te6edf3value Tff7b72=
Te6edf3DiscoveryScanStateTb4b4b4.Te6edf3DwellTb4b4b4(
Te6edf3presetName Tff7b72= Te6edf3presetNameTb4b4b4,
Te6edf3remainingSeconds Tff7b72= Te6edf3remainingTb4b4b4,
Te6edf3totalSeconds Tff7b72= Te6edf3durationSecondsTb4b4b4,
Tb4b4b4)
Te6edf3delayTb4b4b4(Te6edf3TICK_INTERVAL_MSTb4b4b4)
Te6edf3remainingTff7b72-Tff7b72-
Tb4b4b4}
Tff7b72return Tff7b72true
Tb4b4b4}

T8b949e// endregion

T8b949e// region Packet handlers

Tff7b72private Tff7b72fun Td2a8ffhandlePositionTb4b4b4(Te6edf3meshPacketTb4b4b4: Te6edf3MeshPacketTb4b4b4, Te6edf3nodeTb4b4b4: Te6edf3CollectedNodeDataTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3payload Tff7b72= Te6edf3meshPacketTb4b4b4.Te6edf3decodedTff7b72?.Te6edf3payload Tff7b72?: Tff7b72return
Tff7b72val Te6edf3pos Tff7b72= Te6edf3PositionTb4b4b4.Te6edf3ADAPTERTb4b4b4.Te6edf3decodeOrNullTb4b4b4(Te6edf3payloadTb4b4b4, Te6edf3LoggerTb4b4b4) Tff7b72?: Tff7b72return
Tff7b72val Te6edf3lat Tff7b72= Te6edf3posTb4b4b4.Te6edf3latitude_i
Tff7b72val Te6edf3lon Tff7b72= Te6edf3posTb4b4b4.Te6edf3longitude_i
Tff7b72if Tb4b4b4(Te6edf3lat Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3lat Tff7b72!Tff7b72= T79c0ff0Tb4b4b4) Te6edf3nodeTb4b4b4.Te6edf3latitude Tff7b72= Te6edf3lat Tff7b72/ Te6edf3POSITION_DIVISOR
Tff7b72if Tb4b4b4(Te6edf3lon Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3lon Tff7b72!Tff7b72= T79c0ff0Tb4b4b4) Te6edf3nodeTb4b4b4.Te6edf3longitude Tff7b72= Te6edf3lon Tff7b72/ Te6edf3POSITION_DIVISOR
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffhandleTelemetryTb4b4b4(Te6edf3meshPacketTb4b4b4: Te6edf3MeshPacketTb4b4b4, Te6edf3nodeTb4b4b4: Te6edf3CollectedNodeDataTb4b4b4, Te6edf3fromNumTb4b4b4: Tffa657LongTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3payload Tff7b72= Te6edf3meshPacketTb4b4b4.Te6edf3decodedTff7b72?.Te6edf3payload Tff7b72?: Tff7b72return
Tff7b72val Te6edf3telemetry Tff7b72= Te6edf3TelemetryTb4b4b4.Te6edf3ADAPTERTb4b4b4.Te6edf3decodeOrNullTb4b4b4(Te6edf3payloadTb4b4b4, Te6edf3LoggerTb4b4b4) Tff7b72?: Tff7b72return

Tff7b72val Te6edf3deviceMetrics Tff7b72= Te6edf3telemetryTb4b4b4.Te6edf3device_metrics
Tff7b72if Tb4b4b4(Te6edf3deviceMetrics Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3entries Tff7b72= Te6edf3deviceMetricsLogTb4b4b4.Te6edf3getOrPutTb4b4b4(Te6edf3fromNumTb4b4b4) Tb4b4b4{ Te6edf3mutableListOfTb4b4b4(Tb4b4b4) Tb4b4b4}
Te6edf3entriesTb4b4b4.Te6edf3addTb4b4b4(
Te6edf3DeviceMetricsEntryTb4b4b4(
Te6edf3timestamp Tff7b72= Te6edf3nowMillisTb4b4b4,
Te6edf3channelUtil Tff7b72= Te6edf3deviceMetricsTb4b4b4.Te6edf3channel_utilizationTff7b72?.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72?: T79c0ff0.0Tb4b4b4,
Te6edf3airUtilTx Tff7b72= Te6edf3deviceMetricsTb4b4b4.Te6edf3air_util_txTff7b72?.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72?: T79c0ff0.0Tb4b4b4,
Tb4b4b4)Tb4b4b4,
Tb4b4b4)
Tb4b4b4}

Tff7b72if Tb4b4b4(Te6edf3telemetryTb4b4b4.Te6edf3local_stats Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3lastLocalStats Tff7b72= Te6edf3telemetryTb4b4b4.Te6edf3local_stats
Tb4b4b4}

Tff7b72if Tb4b4b4(Te6edf3telemetryTb4b4b4.Te6edf3environment_metrics Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3nodeTb4b4b4.Te6edf3sensorPacketCountTff7b72+Tff7b72+
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffhandleNeighborInfoTb4b4b4(Te6edf3meshPacketTb4b4b4: Te6edf3MeshPacketTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3payload Tff7b72= Te6edf3meshPacketTb4b4b4.Te6edf3decodedTff7b72?.Te6edf3payload Tff7b72?: Tff7b72return
Tff7b72val Te6edf3ni Tff7b72= Te6edf3NeighborInfoTb4b4b4.Te6edf3ADAPTERTb4b4b4.Te6edf3decodeOrNullTb4b4b4(Te6edf3payloadTb4b4b4, Te6edf3LoggerTb4b4b4) Tff7b72?: Tff7b72return
Tff7b72for Tb4b4b4(Te6edf3neighbor Tff7b72in Te6edf3niTb4b4b4.Te6edf3neighborsTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3neighborNum Tff7b72= Te6edf3neighborTb4b4b4.Te6edf3node_idTb4b4b4.Te6edf3toLongTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3node Tff7b72=
Te6edf3collectedNodesTb4b4b4.Te6edf3getOrPutTb4b4b4(Te6edf3neighborNumTb4b4b4) Tb4b4b4{ Te6edf3CollectedNodeDataTb4b4b4(Te6edf3nodeNum Tff7b72= Te6edf3neighborNumTb4b4b4, Te6edf3neighborType Tff7b72= Ta5d6ff"Ta5d6ffmeshTa5d6ff"Tb4b4b4) Tb4b4b4}
T8b949e// Only mark as mesh if not already seen directly
Tff7b72if Tb4b4b4(Te6edf3nodeTb4b4b4.Te6edf3snr Tff7b72=Tff7b72= T79c0ff0f Tff7b72&Tff7b72& Te6edf3nodeTb4b4b4.Te6edf3rssi Tff7b72=Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3nodeTb4b4b4.Te6edf3neighborType Tff7b72= Ta5d6ff"Ta5d6ffmeshTa5d6ff"
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

T8b949e// endregion

T8b949e// region Persistence

Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffReturnCountTa5d6ff"Tb4b4b4)
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffgenerateAiSummariesTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3sessionId Tff7b72=Tff7b72= T79c0ff0L Tff7b72|Tff7b72| Tff7b72!Te6edf3aiProviderTb4b4b4.Te6edf3isAvailableTb4b4b4) Tff7b72return
Tff7b72val Te6edf3session Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3getSessionTb4b4b4(Te6edf3sessionIdTb4b4b4) Tff7b72?: Tff7b72return
Tff7b72val Te6edf3presetResults Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3getPresetResultsTb4b4b4(Te6edf3sessionIdTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3presetResultsTb4b4b4.Te6edf3isEmptyTb4b4b4(Tb4b4b4)Tb4b4b4) Tff7b72return

T8b949e// Generate per-preset AI summaries
Tff7b72for Tb4b4b4(Te6edf3result Tff7b72in Te6edf3presetResultsTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3presetSummary Tff7b72= Te6edf3aiProviderTb4b4b4.Te6edf3generatePresetSummaryTb4b4b4(Te6edf3resultTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3presetSummary Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3discoveryDaoTb4b4b4.Te6edf3updatePresetResultTb4b4b4(Te6edf3resultTb4b4b4.Te6edf3copyTb4b4b4(Te6edf3aiSummary Tff7b72= Te6edf3presetSummaryTb4b4b4)Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// Generate session-level AI summary
Tff7b72val Te6edf3sessionSummary Tff7b72= Te6edf3aiProviderTb4b4b4.Te6edf3generateSessionSummaryTb4b4b4(Te6edf3sessionTb4b4b4, Te6edf3presetResultsTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3sessionSummary Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3discoveryDaoTb4b4b4.Te6edf3updateSessionTb4b4b4(Te6edf3sessionTb4b4b4.Te6edf3copyTb4b4b4(Te6edf3aiSummary Tff7b72= Te6edf3sessionSummaryTb4b4b4)Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffpersistCurrentDwellResultsTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3sessionId Tff7b72=Tff7b72= T79c0ff0LTb4b4b4) Tff7b72return
Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3collectedNodesTb4b4b4.Te6edf3isEmptyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3persistEmptyPresetResultTb4b4b4(Tb4b4b4)
Tff7b72return
Tb4b4b4}

Tff7b72val Te6edf3presetResultId Tff7b72= Te6edf3persistPresetResultTb4b4b4(Tb4b4b4)
Te6edf3persistDiscoveredNodesTb4b4b4(Te6edf3presetResultIdTb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffpersistEmptyPresetResultTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3emptyResult Tff7b72=
Te6edf3DiscoveryPresetResultEntityTb4b4b4(
Te6edf3sessionId Tff7b72= Te6edf3sessionIdTb4b4b4,
Te6edf3presetName Tff7b72= Te6edf3currentPresetNameTb4b4b4,
Te6edf3dwellDurationSeconds Tff7b72= Te6edf3totalDwellSecondsTb4b4b4,
Tb4b4b4)
Te6edf3discoveryDaoTb4b4b4.Te6edf3insertPresetResultTb4b4b4(Te6edf3emptyResultTb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffpersistPresetResultTb4b4b4(Tb4b4b4)Tb4b4b4: Tffa657Long Tb4b4b4{
Tff7b72val Tb4b4b4(Te6edf3avgChannelUtilTb4b4b4, Te6edf3avgAirUtilTb4b4b4) Tff7b72= Te6edf3computeAverageMetricsTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3directCount Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3count Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3neighborType Tff7b72=Tff7b72= Ta5d6ff"Ta5d6ffdirectTa5d6ff" Tb4b4b4}
Tff7b72val Te6edf3meshCount Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3count Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3neighborType Tff7b72=Tff7b72= Ta5d6ff"Ta5d6ffmeshTa5d6ff" Tb4b4b4}
Tff7b72val Te6edf3infraCount Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3count Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3isInfrastructure Tb4b4b4}

Tff7b72val Te6edf3packetsRx Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_packets_rx Tff7b72?: T79c0ff0
Tff7b72val Te6edf3packetsRxBad Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_packets_rx_bad Tff7b72?: T79c0ff0
Tff7b72val Tb4b4b4(Te6edf3successRateTb4b4b4, Te6edf3failureRateTb4b4b4) Tff7b72= Te6edf3computePacketRatesTb4b4b4(Te6edf3packetsRxTb4b4b4, Te6edf3packetsRxBadTb4b4b4)

Tff7b72val Te6edf3presetResult Tff7b72=
Te6edf3DiscoveryPresetResultEntityTb4b4b4(
Te6edf3sessionId Tff7b72= Te6edf3sessionIdTb4b4b4,
Te6edf3presetName Tff7b72= Te6edf3currentPresetNameTb4b4b4,
Te6edf3dwellDurationSeconds Tff7b72= Te6edf3totalDwellSecondsTb4b4b4,
Te6edf3uniqueNodes Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3sizeTb4b4b4,
Te6edf3directNeighborCount Tff7b72= Te6edf3directCountTb4b4b4,
Te6edf3meshNeighborCount Tff7b72= Te6edf3meshCountTb4b4b4,
Te6edf3infrastructureNodeCount Tff7b72= Te6edf3infraCountTb4b4b4,
Te6edf3messageCount Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3sumOf Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3messageCount Tb4b4b4}Tb4b4b4,
Te6edf3sensorPacketCount Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3sumOf Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3sensorPacketCount Tb4b4b4}Tb4b4b4,
Te6edf3avgChannelUtilization Tff7b72= Te6edf3avgChannelUtilTb4b4b4,
Te6edf3avgAirtimeRate Tff7b72= Te6edf3avgAirUtilTb4b4b4,
Te6edf3packetSuccessRate Tff7b72= Te6edf3successRateTb4b4b4,
Te6edf3packetFailureRate Tff7b72= Te6edf3failureRateTb4b4b4,
Te6edf3numPacketsTx Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_packets_tx Tff7b72?: T79c0ff0Tb4b4b4,
Te6edf3numPacketsRx Tff7b72= Te6edf3packetsRxTb4b4b4,
Te6edf3numPacketsRxBad Tff7b72= Te6edf3packetsRxBadTb4b4b4,
Te6edf3numRxDupe Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_rx_dupe Tff7b72?: T79c0ff0Tb4b4b4,
Te6edf3numTxRelay Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_tx_relay Tff7b72?: T79c0ff0Tb4b4b4,
Te6edf3numTxRelayCanceled Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_tx_relay_canceled Tff7b72?: T79c0ff0Tb4b4b4,
Te6edf3numOnlineNodes Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_online_nodes Tff7b72?: T79c0ff0Tb4b4b4,
Te6edf3numTotalNodes Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3num_total_nodes Tff7b72?: T79c0ff0Tb4b4b4,
Te6edf3uptimeSeconds Tff7b72= Te6edf3lastLocalStatsTff7b72?.Te6edf3uptime_seconds Tff7b72?: T79c0ff0Tb4b4b4,
Tb4b4b4)
Tff7b72return Te6edf3discoveryDaoTb4b4b4.Te6edf3insertPresetResultTb4b4b4(Te6edf3presetResultTb4b4b4)
Tb4b4b4}

T8b949e/**
* Computes packet success and failure rates as percentages (0–100) from LocalStats counters. Returns (successRate,
* failureRate). Both are 0.0 if no packets were received.
*/
Tff7b72private Tff7b72fun Td2a8ffcomputePacketRatesTb4b4b4(Te6edf3packetsRxTb4b4b4: Tffa657IntTb4b4b4, Te6edf3packetsRxBadTb4b4b4: Tffa657IntTb4b4b4)Tb4b4b4: Te6edf3PairTff7b72<Tffa657DoubleTb4b4b4, Tffa657DoubleTff7b72> Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3packetsRx Tff7b72<Tff7b72= T79c0ff0Tb4b4b4) Tff7b72return T79c0ff0.0 Te6edf3to T79c0ff0.0
Tff7b72val Te6edf3failureRate Tff7b72= Tb4b4b4(Te6edf3packetsRxBadTb4b4b4.Te6edf3toDoubleTb4b4b4(Tb4b4b4) Tff7b72/ Te6edf3packetsRxTb4b4b4) Tff7b72* Te6edf3PERCENT_MULTIPLIER
Tff7b72val Te6edf3successRate Tff7b72= Te6edf3PERCENT_MULTIPLIER Tff7b72- Te6edf3failureRate
Tff7b72return Te6edf3successRate Te6edf3to Te6edf3failureRate
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffpersistDiscoveredNodesTb4b4b4(Te6edf3presetResultIdTb4b4b4: Tffa657LongTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3session Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3getSessionTb4b4b4(Te6edf3sessionIdTb4b4b4)
Tff7b72val Te6edf3userLat Tff7b72= Te6edf3sessionTff7b72?.Te6edf3userLatitude Tff7b72?: T79c0ff0.0
Tff7b72val Te6edf3userLon Tff7b72= Te6edf3sessionTff7b72?.Te6edf3userLongitude Tff7b72?: T79c0ff0.0

Tff7b72val Te6edf3nodeEntities Tff7b72= Te6edf3collectedNodesTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3map Tb4b4b4{ Te6edf3data Tff7b72-Tff7b72> Te6edf3dataTb4b4b4.Te6edf3toEntityTb4b4b4(Te6edf3presetResultIdTb4b4b4, Te6edf3userLatTb4b4b4, Te6edf3userLonTb4b4b4) Tb4b4b4}
Te6edf3discoveryDaoTb4b4b4.Te6edf3insertDiscoveredNodesTb4b4b4(Te6edf3nodeEntitiesTb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72fun Te6edf3CollectedNodeDataTb4b4b4.Td2a8fftoEntityTb4b4b4(
Te6edf3presetResultIdTb4b4b4: Tffa657LongTb4b4b4,
Te6edf3userLatTb4b4b4: Tffa657DoubleTb4b4b4,
Te6edf3userLonTb4b4b4: Tffa657DoubleTb4b4b4,
Tb4b4b4)Tb4b4b4: Te6edf3DiscoveredNodeEntity Tb4b4b4{
Tff7b72val Te6edf3distance Tff7b72=
Tff7b72if Tb4b4b4(Te6edf3hasValidCoordinatesTb4b4b4(Te6edf3latitudeTb4b4b4, Te6edf3longitudeTb4b4b4) Tff7b72&Tff7b72& Te6edf3hasValidCoordinatesTb4b4b4(Te6edf3userLatTb4b4b4, Te6edf3userLonTb4b4b4)Tb4b4b4) Tb4b4b4{
Te6edf3latLongToMeterTb4b4b4(Te6edf3userLatTb4b4b4, Te6edf3userLonTb4b4b4, Te6edf3latitudeTff7b72!!Tb4b4b4, Te6edf3longitudeTff7b72!!Tb4b4b4)
Tb4b4b4} Tff7b72else Tb4b4b4{
Tff7b72null
Tb4b4b4}
Tff7b72return Te6edf3DiscoveredNodeEntityTb4b4b4(
Te6edf3presetResultId Tff7b72= Te6edf3presetResultIdTb4b4b4,
Te6edf3nodeNum Tff7b72= Te6edf3nodeNumTb4b4b4,
Te6edf3shortName Tff7b72= Te6edf3shortNameTb4b4b4,
Te6edf3longName Tff7b72= Te6edf3longNameTb4b4b4,
Te6edf3neighborType Tff7b72= Te6edf3neighborTypeTb4b4b4,
Te6edf3latitude Tff7b72= Te6edf3latitudeTb4b4b4,
Te6edf3longitude Tff7b72= Te6edf3longitudeTb4b4b4,
Te6edf3distanceFromUser Tff7b72= Te6edf3distanceTb4b4b4,
Te6edf3hopCount Tff7b72= Te6edf3hopCountTb4b4b4,
Te6edf3snr Tff7b72= Te6edf3snrTb4b4b4,
Te6edf3rssi Tff7b72= Te6edf3rssiTb4b4b4,
Te6edf3messageCount Tff7b72= Te6edf3messageCountTb4b4b4,
Te6edf3sensorPacketCount Tff7b72= Te6edf3sensorPacketCountTb4b4b4,
Te6edf3isInfrastructure Tff7b72= Te6edf3isInfrastructureTb4b4b4,
Tb4b4b4)
Tb4b4b4}

T8b949e/** Returns true if both [lat] and [lon] are non-null and non-zero (i.e. a valid GPS fix). */
Tff7b72private Tff7b72fun Td2a8ffhasValidCoordinatesTb4b4b4(Te6edf3latTb4b4b4: Tffa657Double?Tb4b4b4, Te6edf3lonTb4b4b4: Tffa657Double?Tb4b4b4)Tb4b4b4: Tffa657Boolean Tff7b72=
Te6edf3lat Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3lon Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3lat Tff7b72!Tff7b72= T79c0ff0.0 Tff7b72&Tff7b72& Te6edf3lon Tff7b72!Tff7b72= T79c0ff0.0

T8b949e/**
* Computes average channel utilization and airtime from DeviceMetrics, applying the 2-packet rule (only nodes with
* ≥2 reports count).
*/
Tff7b72private Tff7b72fun Td2a8ffcomputeAverageMetricsTb4b4b4(Tb4b4b4)Tb4b4b4: Te6edf3PairTff7b72<Tffa657DoubleTb4b4b4, Tffa657DoubleTff7b72> Tb4b4b4{
Tff7b72val Te6edf3qualifiedEntries Tff7b72= Te6edf3deviceMetricsLogTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3filter Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3size Tff7b72>Tff7b72= Te6edf3MIN_DEVICE_METRICS_PACKETS Tb4b4b4}
Tff7b72if Tb4b4b4(Te6edf3qualifiedEntriesTb4b4b4.Te6edf3isEmptyTb4b4b4(Tb4b4b4)Tb4b4b4) Tff7b72return T79c0ff0.0 Te6edf3to T79c0ff0.0

Tff7b72val Te6edf3avgChannel Tff7b72= Te6edf3qualifiedEntriesTb4b4b4.Te6edf3map Tb4b4b4{ Te6edf3entries Tff7b72-Tff7b72> Te6edf3entriesTb4b4b4.Te6edf3map Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3channelUtil Tb4b4b4}Tb4b4b4.Te6edf3averageTb4b4b4(Tb4b4b4) Tb4b4b4}Tb4b4b4.Te6edf3averageTb4b4b4(Tb4b4b4)

T8b949e// Compute Airtime Rate as (delta air_util_tx / elapsed_time_hours) to match Apple spec FR-008
Tff7b72val Te6edf3avgAirRate Tff7b72=
Te6edf3qualifiedEntries
Tb4b4b4.Te6edf3mapNotNull Tb4b4b4{ Te6edf3entries Tff7b72-Tff7b72>
Tff7b72val Te6edf3first Tff7b72= Te6edf3entriesTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3last Tff7b72= Te6edf3entriesTb4b4b4.Te6edf3lastTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3deltaAir Tff7b72= Te6edf3lastTb4b4b4.Te6edf3airUtilTx Tff7b72- Te6edf3firstTb4b4b4.Te6edf3airUtilTx
Tff7b72val Te6edf3deltaTimeMs Tff7b72= Te6edf3lastTb4b4b4.Te6edf3timestamp Tff7b72- Te6edf3firstTb4b4b4.Te6edf3timestamp
Tff7b72if Tb4b4b4(Te6edf3deltaTimeMs Tff7b72> T79c0ff0Tb4b4b4) Tb4b4b4{
Te6edf3deltaAir Tff7b72/ Tb4b4b4(Te6edf3deltaTimeMs Tff7b72/ T79c0ff3T79c0ff6T79c0ff0T79c0ff0T79c0ff0T79c0ff0T79c0ff0.0Tb4b4b4)
Tb4b4b4} Tff7b72else Tb4b4b4{
Tff7b72null
Tb4b4b4}
Tb4b4b4}
Tb4b4b4.Te6edf3averageTb4b4b4(Tb4b4b4)
Tb4b4b4.Te6edf3takeIf Tb4b4b4{ Tff7b72!Tffa657itTb4b4b4.Te6edf3isNaNTb4b4b4(Tb4b4b4) Tb4b4b4} Tff7b72?: T79c0ff0.0

Tff7b72return Te6edf3avgChannel Te6edf3to Te6edf3avgAirRate
Tb4b4b4}

T8b949e// Guarded by [mutex] so the session-row read-modify-write can't interleave with restoreInterruptedSessionIfAny().
T8b949e// pauseAndAbort() flips isActive to false before finalizing, so without this a reconnect-triggered restore could
T8b949e// race this write on the same row. No caller (runScanLoop, pauseAndAbort, stopScan) holds the mutex here.
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8fffinalizeSessionTb4b4b4(Te6edf3statusTb4b4b4: Tffa657StringTb4b4b4) Tff7b72= Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3sessionId Tff7b72=Tff7b72= T79c0ff0LTb4b4b4) Tff7b72returnTf0883e@withLock
Tff7b72val Te6edf3uniqueCount Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3getUniqueNodeCountTb4b4b4(Te6edf3sessionIdTb4b4b4)
Tff7b72val Te6edf3presetResults Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3getPresetResultsTb4b4b4(Te6edf3sessionIdTb4b4b4)
Tff7b72val Te6edf3session Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3getSessionTb4b4b4(Te6edf3sessionIdTb4b4b4) Tff7b72?: Tff7b72returnTf0883e@withLock
Tff7b72val Te6edf3totalDwell Tff7b72= Te6edf3presetResultsTb4b4b4.Te6edf3sumOf Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3dwellDurationSeconds Tb4b4b4}
Tff7b72val Te6edf3totalMsgs Tff7b72= Te6edf3presetResultsTb4b4b4.Te6edf3sumOf Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3messageCount Tb4b4b4}
Tff7b72val Te6edf3totalSensor Tff7b72= Te6edf3presetResultsTb4b4b4.Te6edf3sumOf Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3sensorPacketCount Tb4b4b4}
Tff7b72val Te6edf3maxDistance Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3getMaxDistanceTb4b4b4(Te6edf3sessionIdTb4b4b4) Tff7b72?: T79c0ff0.0
Tff7b72val Te6edf3avgChanUtil Tff7b72=
Te6edf3presetResults
Tb4b4b4.Te6edf3filter Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3uniqueNodes Tff7b72> T79c0ff0 Tb4b4b4}
Tb4b4b4.Te6edf3map Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3avgChannelUtilization Tb4b4b4}
Tb4b4b4.Te6edf3averageTb4b4b4(Tb4b4b4)
Tb4b4b4.Te6edf3takeIf Tb4b4b4{ Tff7b72!Tffa657itTb4b4b4.Te6edf3isNaNTb4b4b4(Tb4b4b4) Tb4b4b4} Tff7b72?: T79c0ff0.0
Te6edf3discoveryDaoTb4b4b4.Te6edf3updateSessionTb4b4b4(
Te6edf3sessionTb4b4b4.Te6edf3copyTb4b4b4(
Te6edf3totalUniqueNodes Tff7b72= Te6edf3uniqueCountTb4b4b4,
Te6edf3totalDwellSeconds Tff7b72= Te6edf3totalDwellTb4b4b4,
Te6edf3totalMessages Tff7b72= Te6edf3totalMsgsTb4b4b4,
Te6edf3totalSensorPackets Tff7b72= Te6edf3totalSensorTb4b4b4,
Te6edf3furthestNodeDistance Tff7b72= Te6edf3maxDistanceTb4b4b4,
Te6edf3avgChannelUtilization Tff7b72= Te6edf3avgChanUtilTb4b4b4,
Te6edf3completionStatus Tff7b72= Te6edf3statusTb4b4b4,
Tb4b4b4)Tb4b4b4,
Tb4b4b4)
Te6edf3_currentSessionTb4b4b4.Te6edf3value Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3getSessionTb4b4b4(Te6edf3sessionIdTb4b4b4)
Tb4b4b4}

T8b949e// endregion

T8b949e// region Home preset restoration

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffrestoreHomePresetTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3config Tff7b72= Te6edf3originalLoRaConfig Tff7b72?: Tff7b72return
T8b949e// Restore the primary channel first (only when a custom-channel target overwrote it), then the LoRa config —
T8b949e// both are no-ops on a public-only scan, so that path stays unchanged (FR-005 no-regression).
Tff7b72if Tb4b4b4(Te6edf3tunedPrimaryChannelTb4b4b4) Tb4b4b4{
Te6edf3originalPrimaryChannelTff7b72?.Te6edf3let Tb4b4b4{
Te6edf3radioControllerTb4b4b4.Te6edf3setLocalChannelTb4b4b4(Te6edf3ChannelTb4b4b4(Te6edf3index Tff7b72= T79c0ff0Tb4b4b4, Te6edf3role Tff7b72= Te6edf3ChannelTb4b4b4.Te6edf3RoleTb4b4b4.Te6edf3PRIMARYTb4b4b4, Te6edf3settings Tff7b72= Tffa657itTb4b4b4)Tb4b4b4)
Tb4b4b4}
Tb4b4b4}
Te6edf3radioControllerTb4b4b4.Te6edf3setLocalConfigTb4b4b4(Te6edf3ConfigTb4b4b4(Te6edf3lora Tff7b72= Te6edf3configTb4b4b4)Tb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: restored original LoRa configTa5d6ff" Tb4b4b4}
T8b949e// The firmware often restarts the radio or reboots after a LoRa config change.
Te6edf3delayTb4b4b4(T79c0ff3T79c0ff0T79c0ff0T79c0ff0Tb4b4b4)
T8b949e// Wait briefly for reconnection after restoring
Te6edf3waitForConnectionTb4b4b4(Tb4b4b4)
Tb4b4b4}

T8b949e// endregion

T8b949e// region Interrupted-session restoration

T8b949e/**
* Watches for the radio reconnecting and restores any home LoRa config a *previous process's* scan left mid-flight
* — process death, a crash, or BLE loss mid-scan skips [restoreHomePreset] entirely, leaving the session row
* "in_progress" forever and the radio detuned off the user's home mesh until this catches it.
*
* Meant to be `launch`ed once for the app's process lifetime (from app startup) — it never returns normally. Safe
* to call even while this engine is running a legitimate scan of its own: the [isActive] check (taken under
* [mutex], the same lock [startScanTargets] uses) skips every reconnect that belongs to this process's own
* in-progress scan.
*
* @param onRestored invoked with the restored session's home-preset label after a successful restore. The engine
* stays UI-free: localizing that into a user notification is the caller's job (see MeshUtilApplication), which
* also keeps this function unit-testable off a live Compose-resources runtime.
*/
Tff7b72suspend Tff7b72fun Td2a8ffrestoreInterruptedSessionsOnReconnectTb4b4b4(Te6edf3onRestoredTb4b4b4: Te6edf3suspend Tb4b4b4(Te6edf3homePresetTb4b4b4: Tffa657StringTb4b4b4) Tff7b72-Tff7b72> Tffa657Unit Tff7b72= Tb4b4b4{Tb4b4b4}Tb4b4b4) Tb4b4b4{
Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3collect Tb4b4b4{ Te6edf3state Tff7b72-Tff7b72>
Tff7b72if Tb4b4b4(Te6edf3state Tff7b72!is Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4) Tff7b72returnTf0883e@collect
Tff7b72try Tb4b4b4{
Te6edf3restoreInterruptedSessionIfAnyTb4b4b4(Tb4b4b4)Tff7b72?.Te6edf3let Tb4b4b4{ Te6edf3onRestoredTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4}
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3eTb4b4b4: Te6edf3CancellationExceptionTb4b4b4) Tb4b4b4{
Tff7b72throw Te6edf3e
Tb4b4b4} Tff7b72catch Tb4b4b4(Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffTooGenericExceptionCaughtTa5d6ff"Tb4b4b4) Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
T8b949e// A radio write can fail if the link drops right after Connected — the session stays
T8b949e// "interrupted" (it's only marked "restored" after the writes succeed), so the next
T8b949e// reconnect retries. Swallowing keeps this process-lifetime watcher alive.
Te6edf3LoggerTb4b4b4.Te6edf3eTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: interrupted-session restore failed; will retry on reconnectTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

T8b949e/** Restores an interrupted session's home config if one matches the current device; returns its home preset. */
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffrestoreInterruptedSessionIfAnyTb4b4b4(Tb4b4b4)Tb4b4b4: Tffa657String? Tb4b4b4{
Tff7b72val Te6edf3address Tff7b72= Te6edf3meshPrefsTb4b4b4.Te6edf3deviceAddressTb4b4b4.Te6edf3value Tff7b72?: Tff7b72return Tff7b72null
Tff7b72return Te6edf3mutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72if Tb4b4b4(Te6edf3isActiveTb4b4b4) Tff7b72returnTf0883e@withLock Tff7b72null
Tff7b72val Te6edf3session Tff7b72= Te6edf3discoveryDaoTb4b4b4.Te6edf3getInterruptedSessionTb4b4b4(Te6edf3addressTb4b4b4) Tff7b72?: Tff7b72returnTf0883e@withLock Tff7b72null
T8b949e// A session with no captured config can never be restored (the radio config was null at scan start).
T8b949e// Terminalize it so it stops re-matching getInterruptedSession on every future reconnect.
Tff7b72val Te6edf3loraConfig Tff7b72=
Te6edf3sessionTb4b4b4.Te6edf3homeLoraConfig
Tff7b72?: Te6edf3run Tb4b4b4{
Te6edf3discoveryDaoTb4b4b4.Te6edf3updateSessionTb4b4b4(Te6edf3sessionTb4b4b4.Te6edf3copyTb4b4b4(Te6edf3completionStatus Tff7b72= Ta5d6ff"Ta5d6ffunrestorableTa5d6ff"Tb4b4b4)Tb4b4b4)
Tff7b72returnTf0883e@withLock Tff7b72null
Tb4b4b4}
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{ Ta5d6ff"Ta5d6ffDiscoveryScanEngine: restoring home config after interrupted session Tffd700${Te6edf3sessionTb4b4b4.Te6edf3idTffd700}Ta5d6ff" Tb4b4b4}
Te6edf3sessionTb4b4b4.Te6edf3homePrimaryChannelTff7b72?.Te6edf3let Tb4b4b4{
Te6edf3radioControllerTb4b4b4.Te6edf3setLocalChannelTb4b4b4(Te6edf3ChannelTb4b4b4(Te6edf3index Tff7b72= T79c0ff0Tb4b4b4, Te6edf3role Tff7b72= Te6edf3ChannelTb4b4b4.Te6edf3RoleTb4b4b4.Te6edf3PRIMARYTb4b4b4, Te6edf3settings Tff7b72= Tffa657itTb4b4b4)Tb4b4b4)
Tb4b4b4}
Te6edf3radioControllerTb4b4b4.Te6edf3setLocalConfigTb4b4b4(Te6edf3ConfigTb4b4b4(Te6edf3lora Tff7b72= Te6edf3loraConfigTb4b4b4)Tb4b4b4)
Te6edf3discoveryDaoTb4b4b4.Te6edf3updateSessionTb4b4b4(Te6edf3sessionTb4b4b4.Te6edf3copyTb4b4b4(Te6edf3completionStatus Tff7b72= Ta5d6ff"Ta5d6ffrestoredTa5d6ff"Tb4b4b4)Tb4b4b4)
Te6edf3sessionTb4b4b4.Te6edf3homePreset
Tb4b4b4}
Tb4b4b4}

T8b949e// endregion

T8b949e// region Lifecycle helpers

Tff7b72private Tff7b72fun Td2a8ffcancelScanInternalTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3collectorRegistryTb4b4b4.Te6edf3collector Tff7b72= Tff7b72null
Te6edf3dwellJobTff7b72?.Te6edf3cancelTb4b4b4(Tb4b4b4)
Te6edf3dwellJob Tff7b72= Tff7b72null
Te6edf3scanScopeTff7b72?.Te6edf3cancelTb4b4b4(Tb4b4b4)
Te6edf3scanScope Tff7b72= Tff7b72null
Tb4b4b4}

T8b949e// endregion

Tff7b72companion Tff7b72object Tb4b4b4{
Tff7b72private Tff7b72const Tff7b72val Te6edf3RECONNECT_TIMEOUT_MS Tff7b72= T79c0ff6T79c0ff0Te6edf3_000L
Tff7b72private Tff7b72const Tff7b72val Te6edf3TICK_INTERVAL_MS Tff7b72= T79c0ff1Te6edf3_000L
Tff7b72private Tff7b72const Tff7b72val Te6edf3POSITION_DIVISOR Tff7b72= T79c0ff1Te6edf3e7
Tff7b72private Tff7b72const Tff7b72val Te6edf3MIN_DEVICE_METRICS_PACKETS Tff7b72= T79c0ff2
Tff7b72private Tff7b72const Tff7b72val Te6edf3PERCENT_MULTIPLIER Tff7b72= T79c0ff1T79c0ff0T79c0ff0.0

T8b949e/** Node roles that indicate infrastructure (Router, RouterLate, ClientBase). */
Tff7b72private Tff7b72val Te6edf3INFRASTRUCTURE_ROLES Tff7b72=
Te6edf3setOfTb4b4b4(
Te6edf3ConfigTb4b4b4.Te6edf3DeviceConfigTb4b4b4.Te6edf3RoleTb4b4b4.Te6edf3ROUTERTb4b4b4,
Te6edf3ConfigTb4b4b4.Te6edf3DeviceConfigTb4b4b4.Te6edf3RoleTb4b4b4.Te6edf3ROUTER_LATETb4b4b4,
Te6edf3ConfigTb4b4b4.Te6edf3DeviceConfigTb4b4b4.Te6edf3RoleTb4b4b4.Te6edf3CLIENT_BASETb4b4b4,
Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Served by rngit 1.5.0 - Generated in 0.18s